Skip to content

S05-02 多线程与并发-线程安全与同步

[TOC]

线程安全与同步

线程安全问题

在多线程编程中,线程安全(Thread Safety)同步(Synchronization) 是最核心、也是最容易出错的领域。

当多个线程同时访问和修改同一个共享且可变的资源(例如堆内存中的对象属性、静态变量)时,如果得不到正确的控制,就会引发数据不一致的问题。这就是所谓的竞态条件(Race Condition)

我们先看一个经典的非线程安全例子:

java
public class Counter {
  private int count = 0;

  public void increment() {
    count++; // 这行代码看似只有一步,但在底层是三步:读、加、写
  }
}

如果 1000 个线程同时调用 increment(),最终的 count 值大概率会小于 1000。因为线程 A 读取了 count=5 还没来得及加 1 并写回,线程 B 也读取了 count=5。最后两个线程都把 6 写回内存,导致丢失了一次更新。


示例:开启三个窗口售票,总票数为100张

  1. 实现方式一:继承 Thread 类

    image-20260506171351338

    安全问题1:上述示例中出现了3个窗口(线程)同时卖了第 100 张票的问题。

    安全问题2:如果在循环中添加 sleep() 方法,还会出现错票(-1):

    image-20260506171845636

  2. 实现方式二:实现 Runnable 接口

    image-20260506172645306

    安全问题:方式二同样会出现方式一中的重票和错票的问题。

  3. 图解分析

    线程1操作 ticket 的过程中,尚未结束的情况下,其他线程也参与进来,对 ticket 进行操作就导致了重票和错票的问题。

    image-20260506173046792

  4. 解决方案

    必须保证一个线程 a 在操作 ticket 的过程中,其他线程必须等待,直到线程 a 操作 ticket 结束以后,其他线程才可以进来继续操作 ticket。

    Java 中可以使用线程的同步机制来解决。

同步机制方案

为了保证线程安全,Java 提供了多种同步机制。以下是这些机制的详细剖析:

方案1:synchronized

synchronized 关键字 是 Java 内置的同步机制,它基于对象的监视器锁(Monitor,同步监视器)来实现。它是一种悲观锁,保证了操作的原子性可见性哪个线程获取了监视器锁,就能执行需要被同步的代码。监视器锁可以使用任何一个类的对象充当,但是多个线程必须共用同一个监视器锁。

它可以修饰方法或代码块:

修饰实例方法

① 修饰实例方法(同步方法):

锁住的是当前的实例对象(this。如果两个线程访问同一个对象的同步方法,它们会互斥;但如果访问的是不同对象的同步方法,则互不干扰。

java
public synchronized void increment() {
  count++;
}

示例:同步方法解决线程安全问题

image-20260506222522072

修饰静态方法

② 修饰静态方法(同步静态方法):

锁住的是当前类的 Class 对象(即全局锁)。无论创建多少个实例,调用该静态方法时所有线程都会互斥。

java
public static synchronized void print() {
  System.out.println("这是一个静态同步方法");
}

示例:同步静态方法解决线程安全问题

image-20260506223403006

image-20260506223127211

修饰代码块(推荐)

③ 修饰代码块(同步代码块,推荐):

相比于锁住整个方法,锁住代码块可以缩小锁的粒度(即缩小临界区),只对真正需要同步的代码加锁,从而提高程序的并发性能。

java
public void increment() {
  // 假设这里有很多不需要同步的耗时代码

  // 括号里指定要锁定的对象(同步监视器,锁)
  synchronized(this) {
    // 需要被同步的代码
    count++;
  }
}

在 IDEA 等现代 IDE 中,通常会提示你尽量减小 synchronized 的范围,以优化性能


示例:同步代码块解决 Runnable 接口的线程安全问题

注意

  • 监视器锁可以是任意的对象(但必须保证它在多个线程中是唯一的)。
  • 而类中也存在符合该条件的唯一对象 this,可以用它作为监视器锁 synchronized(this) {}

image-20260506175553034

图解分析

image-20260506175750990

错误写法

while 循环放到同步代码块内部。会造成一直是一个线程在卖票,其他线程只能等票卖完(while循环结束)才能抢到窗口。

image-20260506180556157


示例:同步代码块解决继承 Thread 类的线程安全问题

注意

  • 在该方式中不能使用 this 作为监视器锁,因为后面会创建多个 Window 实例,无法保证 this 的唯一性
  • 可以使用 static 修饰的 new Object() 对象或者 Window.class 作为监视器锁,它们是唯一的。

image-20260506220838969

优缺点

优点

  • 解决了多线程的安全问题。

缺点

  • 在操作共享数据时,多线程其实是串行执行的,性能较低。
练习:账户线程安全问题

题目:银行有一个账户,有两个储户分别向同一个账户存3000元,每次存1000,存3次。每次存完打印账户余额。该程序是否有安全问题?如果有该如何解决?

提示

  • 明确哪些代码是多线程运行代码,需写入 run() 方法。
  • 明确什么是共享数据。
  • 明确多线程运行代码中哪些语句是操作共享数据的。

解答(存在线程安全问题)

image-20260507153321754

解答(解决上述问题)

image-20260507153604926

懒汉式线程安全

懒汉式单例

问题线程不安全。多线程下,两个线程同时判断 instance == null,会创建两个对象!

java
class Bank {
    // 1. 不在初始化属性时创建实例对象
    private static Bank INSTANCE = null;

    private Bank () {}

    public static Bank getInstance() {
        // 2. 只有当第一次调用 getInstance() 时才去创建实例对象
        if(INSTANCE == null) {
            // 3. 设置线程阻塞,显现线程不安全问题
          	try {
              Thread.sleep(1000);
            } catch(InterruptedException e) {
              e.printStackTrace();
            }

            INSTANCE = new Bank();
        }
        return INSTANCE;
    }
}

线程不安全演示

java
public static void main(String[] args) {
  // 1. 开启多线程
  Thread t1 = new Thread(){
    public void run(){
      s1 = Bank.getInstance();
    }
  };
  Thread t2 = new Thread(){
    public void run(){
      s2 = Bank.getInstance();
    }
  };
  t1.start();
  t2.start();

  // 2. 等待线程 t1,t2 执行完毕,才执行 main 线程
  try {
    t1.join();
    t2.join();
  } catch (InterruptedException e) {
    e.printStackTrace();
  }

  // 3. 对比不同线程中创建的单例对象是否是同一个对象
  System.out.println(s1);
  System.out.println(s2);
  System.out.println(s1 == s2); // false,2次创建的对象不是同一个对象。
}

解决方案

  1. 方式一:使用 synchronized 修饰静态方法

    java
    // 使用 synchronized 修饰方法,解决多线程的安全问题
    public static synchronized Bank getInstance() { // 同步监视器:Bank.class
      if(INSTANCE == null) {
        try {
          Thread.sleep(1000);
        } catch(InterruptedException e) {
          e.printStackTrace();
        }
    
        INSTANCE = new Bank();
      }
      return INSTANCE;
    }
  2. 方式二:使用 synchronized 修饰代码块

    java
    public static Bank getInstance() {
      // 使用 synchronized 修饰代码块,解决多线程的安全问题
      synchronized(Bank.class) { // 同步监视器:Bank.class
        if(INSTANCE == null) {
          try {
            Thread.sleep(1000);
          } catch(InterruptedException e) {
            e.printStackTrace();
          }
    
          INSTANCE = new Bank();
        }
      }
      return INSTANCE;
    }
  3. 方式三:优化方式二,提升效率

    java
    public static Bank getInstance() {
      // 在执行同步代码块前,先判断实例是否已经创建,如果已经创建,则不执行同步代码块,提升效率
      if(INSTANCE == null) {
        synchronized(Bank.class) {
          if(INSTANCE == null) { // 不能省略
            try {
              Thread.sleep(1000);
            } catch(InterruptedException e) {
              e.printStackTrace();
            }
    
            INSTANCE = new Bank();
          }
        }
      }
    
      return INSTANCE;
    }

    问题:指令重排。从JDK2开始,分配空间、初始化、调用构造函数会在线程的工作存储区一次性完成,然后复制到主存储区。但是需要 volatile 关键字,避免指令重排。

    java
    class Bank {
      // 需要使用 volatile 修饰 INSTANCE,避免指令重排
      private static volatile Bank INSTANCE = null;
    
      private Bank () {}
    
      public static Bank getInstance() {
        if(INSTANCE == null) {
          synchronized(Bank.class) {
            if(INSTANCE == null) {
              try {
                Thread.sleep(1000);
              } catch(InterruptedException e) {
                e.printStackTrace();
              }
    
              INSTANCE = new Bank();
            }
          }
        }
    
        return INSTANCE;
      }
    }

方案2:volatile

轻量级同步:volatile 关键字:

volatile 是 Java 提供的一种轻量级的同步机制,它只能修饰变量

  • 保证可见性: 当一个线程修改了 volatile 变量,新值会立刻刷新到主内存,并强制其他线程的工作内存中该变量的缓存失效,必须重新去主内存读取。
  • 禁止指令重排序: 编译器和 CPU 在优化代码时不会随意改变 volatile 变量读写操作前后的代码执行顺序。
  • 致命弱点(不保证原子性):count++ 这样的复合操作,用 volatile 修饰 count 依然是不安全的。

典型应用场景: 状态标志位。

java
public class Task {
  // 使用 volatile 保证其他线程能立刻看到停止信号
  private volatile boolean isRunning = true;

  public void stop() {
    isRunning = false;
  }

  public void run() {
    while (isRunning) {
      // 执行任务
    }
  }
}

方案3:Lock

高级同步机制:Lock 接口(JUC 包):

从 JDK 1.5 开始,java.util.concurrent.locks 包提供了显式锁(Explicit Lock)。最常用的是 ReentrantLock(可重入锁)

API:Lock 接口

与隐式的 synchronized 相比,Lock 需要手动获取和释放锁,但它提供了更强大的功能:

  • 响应中断: 等待锁的线程可以被中断(lockInterruptibly())。
  • 非阻塞尝试: 可以尝试获取锁,获取不到直接返回,而不是一直傻等(tryLock())。
  • 公平锁: 可以设置为公平模式(按线程排队的顺序获取锁),而 synchronized 只能是非公平锁。

Lock 接口 API

  • void lock()()阻塞不响应中断获取锁。拿不到就一直死等。

  • void lockInterruptibly()()阻塞响应中断获取锁。拿不到就等,但等待期间可以被中断

  • boolean tryLock()()无需响应中断尝试获取锁。拿到返回 true,拿不到立刻返回 false,绝不等待。

  • boolean tryLock()(long time, TimeUnit unit)限时阻塞响应中断在指定时间内尝试获取锁。超时返回 false

  • void unlock()()释放锁

  • Condition newCondition()(),返回一个绑定到此 Lock 实例的新 Condition 对象(用于精确唤醒)。


示例:标准使用范式(必须在 finally 中释放锁):

java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockCounter {
  private int count = 0;
  // 创建一个可重入锁
  private final Lock lock = new ReentrantLock(); 

  public void increment() {
    lock.lock();  // 阻塞式获取锁
    try {
      count++; // 保护临界区
    } finally {
      lock.unlock(); // 极其重要:务必在 finally 中释放锁,可以确保 unlock() 一定会被执行,防止死锁!
    }
  }
}

示例:使用 Lock 接口解决多线程售票的安全问题

java
import java.util.concurrent.locks.ReentrantLock;

class Window implements Runnable{
  int ticket = 100;
  // 1. 创建一个可重入锁,需要确保多个线程共用同一个 Lock 实例
  private final ReentrantLock lock = new ReentrantLock();

  public void run(){
    while(true){
      try{
        // 2. 阻塞式获取锁
        lock.lock();

        if(ticket > 0){
          try {
            Thread.sleep(10);
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
          System.out.println(ticket--);
        }else{
          break;
        }
      // 3. 极其重要:务必在 finally 中释放锁,可以确保 unlock() 一定会被执行,防止死锁!
      }finally{
        lock.unlock();
      }
    }
  }
}

public class ThreadLock {
  public static void main(String[] args) {
    Window t = new Window();

    Thread t1 = new Thread(t);
    Thread t2 = new Thread(t);

    t1.start();
    t2.start();
  }
}
Lock vs synchronized
特性synchronized (隐式锁)Lock (显式锁)
底层实现JVM 层面的 C++ 实现 (Monitor)JDK 层面的 Java 代码实现 (基于 AQS)
灵活性只能死等,拿不到就一直阻塞支持非阻塞(tryLock)、可中断、限时等待
释放锁的方式自动释放(代码执行完或抛异常自动释放)必须手动释放 (finally 中调用 unlock)
公平性只能是非公平锁既支持非公平锁,也支持公平锁(先排队先得)
锁的粒度方法或代码块更加灵活,甚至可以跨方法加锁和解锁
读写锁分离不支持(读和读之间也会互斥)支持 (ReadWriteLock),提高读多写少场景的性能

为了应对读多写少的场景,JUC 还提供了一个非常巧妙的锁机制。

方案4:Atomic 原子类

乐观锁与无锁并发:Atomic 原子类:

JUC 的 java.util.concurrent.atomic 包下提供了一系列原子类(如 AtomicIntegerAtomicLongAtomicReference 等)。

它们不使用传统的互斥锁(不挂起线程),而是基于底层的 CAS(Compare-And-Swap,比较并交换) 硬件指令来实现线程安全。这是一种乐观锁策略,在读多写少、竞争不太激烈的场景下,性能远高于 synchronizedReentrantLock

java
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
  // 内部封装着 volatile 变量,并使用 CAS 保证更新原子性
  private AtomicInteger count = new AtomicInteger(0);

  public void increment() {
    count.incrementAndGet(); // 等价于 count++ 的线程安全版本
  }
}

方案5:ThreadLocal

避免共享:ThreadLocal:

有时候,与其费尽心思去同步共享变量,不如干脆不共享ThreadLocal 提供了一种机制:它为每个使用该变量的线程都提供一个独立的变量副本。线程各自修改自己的副本,互不影响。

典型应用: 数据库连接管理(每个线程一个独立的 Connection)、Spring 中的事务管理、存放用户 Session 信息。

总结与选型指南

总结与选型指南:

  1. 能不共享就不共享: 优先考虑局部变量或 ThreadLocal

  2. 简单的原子计数/累加: 毫不犹豫地使用 AtomicInteger 等原子类。

  3. 代码块/方法需要互斥: 优先使用 synchronized(现在的 JDK 对其做了大量优化,引入了偏向锁、轻量级锁,性能已经非常优秀,且代码最简洁)。

  4. 需要高级锁控制(如尝试锁、公平锁、中断等待): 使用 ReentrantLock

这四大主流同步方案在日常的 Java 开发中支撑了绝大多数的并发场景。

死锁

在多线程编程中,死锁(Deadlock) 是最令人头疼的 Bug 之一。它不会像抛出异常那样让程序立刻崩溃并给出明确的错误提示,而是会导致相关线程永久“卡死”,不仅消耗系统资源,还会让业务逻辑陷入停滞。

通俗比喻:

想象一条只能单向通行的极窄小桥,两个人分别从桥的两头往中间走。到了桥中间,两人面对面顶住了。

A 说:“你退回去让我先过,我就往前走。”

B 说:“凭什么?你先退回去让我过,我才往前走。”

结果谁也不肯让步,两人就在桥上永远僵持下去了。这就是死锁。

死锁产生条件

产生死锁的四个必要条件:

计算机科学家 Coffman 总结过,一个死锁的发生,必须同时满足以下四个条件(缺一不可):

  1. 互斥条件(Mutual Exclusion): 资源是独占的,每次只能被一个线程使用。如果是可以多线程共享读的资源,就不会产生死锁。

  2. 占用且等待(Hold and Wait): 线程 A 已经拿到了一部分资源(保持),但还需要另外的资源才能继续执行(请求),在等待新资源的时候,它绝不释放自己已经拿到的老资源。

  3. 不可抢夺(No Preemption): 线程已经获得的资源,在未使用完之前,操作系统或别的线程不能强行把它抢走,只能由该线程自己主动释放。

  4. 循环等待(Circular Wait): 存在一个等待环路。比如线程 A 等待线程 B 占用的资源,线程 B 等待线程 C 占用的资源,线程 C 又在等待线程 A 占用的资源。

image-20260507172544763

经典死锁

最常见的死锁场景就是嵌套锁(在持有一个锁的情况下去申请另一个锁),且加锁的顺序不一致。

代码演示:

java
public class DeadlockDemo {
  // 定义两个公共锁对象
  private static final Object lockA = new Object();
  private static final Object lockB = new Object();

  public static void main(String[] args) {
    // 线程 1:先获取 A 锁,再获取 B 锁
    Thread thread1 = new Thread(() -> {
      synchronized (lockA) {
        System.out.println("线程 1:已获取 lockA,准备获取 lockB...");
        try {
          Thread.sleep(100); // 睡眠 100ms,确保线程 2 有时间获取 lockB
        } catch (InterruptedException e) {}

        synchronized (lockB) {
          System.out.println("线程 1:已同时获取 lockA 和 lockB!");
        }
      }
    });

    // 线程 2:先获取 B 锁,再获取 A 锁
    Thread thread2 = new Thread(() -> {
      synchronized (lockB) {
        System.out.println("线程 2:已获取 lockB,准备获取 lockA...");
        try {
          Thread.sleep(100); // 睡眠 100ms,确保线程 1 有时间获取 lockA
        } catch (InterruptedException e) {}

        synchronized (lockA) {
          System.out.println("线程 2:已同时获取 lockB 和 lockA!");
        }
      }
    });

    thread1.start();
    thread2.start();
  }
}

运行结果:

程序打印出前两行提示后,就会陷入无尽的等待,永远无法退出,也不会打印后续内容。

死锁排查

如何排查和定位线上死锁:

死锁发生时,程序表面上可能毫无异样(除非所有提供 Web 服务的线程都被死锁卡住导致拒绝服务)。排查死锁是 Java 程序员必备的排错技能:

  • 工具一:jstack(命令行)

    1. 先用 jps -l 命令找到 Java 进程的 PID。
    2. 执行 jstack <PID> 打印该进程当前的线程快照(Thread Dump)。
    3. 把日志翻到最后,JVM 会非常贴心地直接告诉你:Found one Java-level deadlock:,并把互相锁住的线程名字、代码行号、锁对象的内存地址全部列出来。
  • 工具二:JConsole 或 VisualVM(可视化界面)

    这是 JDK 自带的图形化监控工具。连接到对应的 Java 进程后,进入“线程”面板,点击“检测死锁(Detect Deadlock)”按钮,图形界面会直观地展示死锁详情。

死锁解决方案

解决与预防死锁的核心策略:

既然死锁必须满足四个条件,我们只要破坏其中任意一个,死锁就被破解了。在实际开发中,最常用的策略有以下几种:

方案1:锁排序法

方案一:锁排序法(破坏“循环等待”条件)—— 最常用、最有效:

如果所有的线程都按照绝对相同的顺序去获取锁,死锁就永远不会发生。

  • 在上面的例子中,如果强制要求业务逻辑:无论哪个线程,只要需要同时用到 A 和 B,都必须先获取 A 再获取 B,死锁就解除了。
  • 实战场景:银行转账(A 账户转给 B 账户,B 账户转给 A 账户)。此时不能简单地“先锁转出账户,再锁转入账户”,而是应该根据账户的 ID(如卡号)进行大小排序,永远先锁 ID 小的账户,再锁 ID 大的账户。
方案2:超时尝试机制

方案二:超时尝试机制(破坏“占用且等待”条件)

放弃使用 synchronized,改用 JUC 包下的 ReentrantLock。它提供了一个带有超时参数的尝试加锁方法 tryLock(long timeout, TimeUnit unit)

  • 可以考虑一次性申请所有所需的资源。
  • 如果线程在指定时间内拿不到全部的锁,它就会主动放弃,并释放自己已经拿到的所有锁,休眠一会再重新尝试。这就打破了“拿着不放”的僵局。
方案3:使用更大的锁粒度或避免嵌套锁

方案三:使用更大的锁粒度或避免嵌套锁:

尽量减少在持有一个锁的同时去调用其他可能会导致阻塞的方法,或者把多个小锁合并成一个大锁(虽然这会降低并发性能,但极大地提高了安全性)。

线程通信

在多线程编程中,线程同步(如 synchronizedLock)主要是为了解决多个线程争抢同一个资源时的“冲突”问题,也就是互斥

线程通信(Thread Communication) 则是为了解决多个线程如何协同工作的问题。当一个线程的执行需要依赖另一个线程的处理结果或某种状态变化时,它们之间就需要相互“打招呼”和传递信号。

Java 提供了多种机制来实现线程间的通信,从底层的对象监视器到高阶的并发集合,以下是详细的分类和介绍:

方案1:共享内存 + volatile

方案1:共享内存 + volatile

这是最简单的通信方式。多个线程通过读写同一个共享变量来实现状态的传递。

  • 机制: 线程 A 修改了共享变量的值,线程 B 读取这个共享变量的值。
  • 关键点: 必须使用 volatile 关键字修饰该共享变量。volatile 保证了变量的可见性,即线程 A 修改后,会立刻强制刷新到主内存,并让线程 B 的本地缓存失效,从而确保线程 B 马上能读到最新的信号。
  • 适用场景: 简单的状态标志位(如控制线程启停的 boolean isRunning 标志)。

方案2:Wait / Notify 机制

API:Object

Object 类 API

在超类 Object 中,定义了以下三个专门用于线程通信的方法:

  • final void wait()()

    final void wait()(long timeout)

    final void wait()(long timeout, int nanos),让当前正在执行的线程进入 等待状态(WAITING),并立刻释放它持有的对象锁
    线程会一直在该对象的等待队列中沉睡,直到被其他线程唤醒(或超时)。

  • final void notify()()随机唤醒在该对象等待队列中沉睡的一个线程。
    被唤醒的线程不会立刻执行,而是进入同步队列,重新去竞争锁。

  • final void notifyAll()(),唤醒在该对象等待队列中的所有线程。
    这些线程会全部进入同步队列竞争锁。在实际开发中,通常推荐使用 notifyAll() 以防止信号丢失(假死现象)。

⚠️ 极其重要的铁律:

wait()notify()notifyAll() 必须且只能在 synchronized 同步代码块或同步方法中调用,并且调用者必须是锁对象本身。否则会抛出 IllegalMonitorStateException 异常。

Wait / Notify 机制

方案2:Wait / Notify 机制 (基于 Object 类):

这是 Java 面试中最常被问到的通信机制。Java 中的每一个对象都有两个与之关联的队列:同步队列(存放争抢锁的线程)和等待队列(存放调用了 wait() 的线程)。

示例:多个线程交替打印1-100的值

java
class Communication implements Runnable {
  int i = 1;
  public void run() {
    while (true) {
      synchronized (this) {
        // 2. 随机唤醒在该对象等待队列中沉睡的一个线程。被唤醒的线程不会立刻执行,而是进入同步队列,重新去竞争锁。
        notify();

        if (i <= 100) {
          try {
            Thread.sleep(100);
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
          System.out.println(Thread.currentThread().getName() + ":" + i++);
        } else {
          break;
        }

        try {
        	// 1. 线程一旦执行此方法,就进入等待状态,同时会释放锁
          wait();
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    }
  }
}
java
public class CommunicationTest {
  public static void main(String[] args) {
    Communication c = new Communication();

		Thread t1 = new Thread(c, "线程1");
		Thread t2 = new Thread(c, "线程2");
    t1.start();
 		t2.start();
  }
}

方案3:Condition 接口

方案3:Condition 接口 (基于 Lock):

虽然 wait/notify 很好用,但它有一个致命缺陷:无法精确唤醒指定的线程notify 是随机唤醒一个,notifyAll 是唤醒所有)。

为了解决这个问题,JUC 包下的 Lock 接口提供了 Condition(条件变量)机制。

  • 核心方法:

    • await():等同于 Object.wait()
    • signal():等同于 Object.notify()
    • signalAll():等同于 Object.notifyAll()
  • 优势:精确打击(多路等待/唤醒)

    一个 ReentrantLock 可以绑定多个 Condition 对象。比如在经典的“生产者-消费者”模型中,你可以创建一个“生产者 Condition”和一个“消费者 Condition”。当队列满了,生产者调用 await() 休息;当消费者消费了一个元素后,可以直接调用生产者 Conditionsignal()精确地只唤醒生产者线程,而不惊动其他正在休息的消费者。

方案4:阻塞队列

方案4:阻塞队列 (BlockingQueue):

在实际的企业级开发中,我们极少手动去写 wait/notifyCondition,因为极容易写出死锁或并发 Bug。

Java JUC 提供了 BlockingQueue(阻塞队列)接口,它在底层已经完美封装好了线程通信和同步的逻辑。它非常适合用来实现生产者-消费者模型

  • 当队列为空时: 尝试从队列中获取元素(如调用 take())的消费者线程会被自动阻塞(通信等待)。
  • 当队列满时: 尝试向队列中添加元素(如调用 put())的生产者线程会被自动阻塞(通信等待)。
  • 自动唤醒: 只要队列有空间了,或者有新元素进来了,底层的 Condition 机制会自动互相唤醒,开发者完全不需要关心锁和信号量。

常见的实现类有 ArrayBlockingQueue(有界)和 LinkedBlockingQueue(链表无界/有界)。

方案5:管道通信

方案5:管道通信:PipedInputStream / PipedOutputStream:

主要用于两个线程之间直接传输字节流/字符流数据,就像接了一根水管一样。

  • 工作原理: 线程 A 往 PipedOutputStream 写入数据,线程 B 从 PipedInputStream 读取数据。这两个流必须在代码中显式连接起来(connect() 方法)。
  • 适用场景: 纯内存中的数据流式传输。如果读线程读取速度慢于写线程,写线程会自动阻塞等待。不过在现代高并发开发中,由于有更加高效的队列机制,管道流的使用频率已经相对较低了。

方案6:Thread.join()

方案6:线程执行交接:Thread.join():

当你需要一个线程等待另一个线程执行完毕后,才能继续往下走时,使用 join() 是最简单的通信方式。

  • 机制: 在主线程中调用 t1.join(),主线程会立刻进入等待状态,直到 t1 线程的代码全部执行完毕(生命周期结束),主线程才会被唤醒继续执行。
  • 底层: join() 的底层其实就是不断循环调用 wait() 检查目标线程是否还存活。

Thread.sleep() vs Object.wait()

Thread.sleep()Object.wait() 的区别:

虽然它们都能让线程暂停执行,但有本质区别:

  • 所属类不同:

    • sleep()Thread 类的静态方法;
    • wait() 是所有对象的顶级父类 Object 的方法。
  • 对锁的处理不同(核心):

    • 线程在 sleep 的时候,如果持有某个对象的锁,它绝不会释放锁(抱着锁睡觉);
    • 而线程在调用 wait() 时,会立刻释放它持有的对象锁,让其他等待该锁的线程有机会进入同步代码块。
  • 使用场景不同

    • wait() 只能使用在同步代码块或同步方法中;
    • sleep() 可以在任何需要使用的场景。
  • 结束阻塞的方式不同

    • wait() 到达指定时间或通过 notify() 唤醒可以结束阻塞;
    • sleep() 到达指定时间后自动结束阻塞。

了解了 Thread 类的底层结构后,我们通常在复杂场景下需要安全地停止一个正在运行的线程。由于 Java 早已废弃了粗暴的 stop() 方法,现在业界通用的做法是使用中断机制

总结对比

总结对比:

通信机制适用场景复杂度推荐程度
volatile 变量传递简单的开关/标志位状态极低⭐⭐⭐⭐
Object (wait/notify)基础的线程等待与唤醒协调较高(易死锁)⭐⭐
Condition (await/signal)需要精确控制特定线程组的唤醒⭐⭐⭐
BlockingQueue生产者-消费者模型,解耦业务极低(已封装好)⭐⭐⭐⭐⭐
Thread.join()等待其他线程初始化或计算完毕⭐⭐⭐

理解了这些通信机制,你就具备了编写复杂协同任务的能力。

生产者与消费者问题

什么是生产者与消费者问题

简单来说,就是有两个或两类线程:

  • 生产者(Producer): 负责不断地生成数据(任务)。
  • 消费者(Consumer): 负责不断地处理数据(执行任务)。

它们之间共享一个固定大小的缓冲区(Buffer/Queue)

  • 生产者将数据放入缓冲区。
  • 消费者从缓冲区中取出数据。

核心矛盾与规则:

  1. 解耦与缓冲: 生产者和消费者的处理速度往往是不一致的。如果没有缓冲区,生产者太快会把消费者“撑死”,消费者太快会经常“空转”。

  2. 同步机制(关键):

    • 当缓冲区时,生产者必须停止生产并等待(阻塞),直到消费者取走数据腾出空间。
    • 当缓冲区时,消费者必须停止消费并等待(阻塞),直到生产者放入新数据。


示例:生产者与消费者问题

生产者(Productor)将产品交给店员(Clerk),而消费者(Customer)从店员处取走产品,店员一次只能持有固定数量的产品(比如:20),如果生产者试图生产更多的产品,店员会叫生产者停一下,如果店中有空位放产品了再通知生产者继续生产;如果店中没有产品了,店员会告诉消费者等一下,如果店中有产品了再通知消费者来取走产品。

代码实现

  1. 主线程

    java
    public class ConsumerProducerTest {
      public static void main(String[] args) {
        Clerk clerk = new Clerk();
    
        Producer p1 = new Producer(clerk);
    
        Consumer c1 = new Consumer(clerk);
        Consumer c2 = new Consumer(clerk);
    
        p1.setName("生产者1");
        c1.setName("消费者1");
        c2.setName("消费者2");
    
        p1.start();
        c1.start();
        c2.start();
      }
    }
  2. 生产者

    java
    //生产者
    class Producer extends Thread{
      private Clerk clerk;
    
      public Producer(Clerk clerk){
        this.clerk = clerk;
      }
    
      @Override
      public void run() {
        System.out.println("=========生产者开始生产产品========");
        while(true){
    
          try {
            Thread.sleep(40);
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
    
          //要求clerk去增加产品
          clerk.addProduct();
        }
      }
    }
  3. 消费者

    java
    //消费者
    class Consumer extends Thread{
      private Clerk clerk;
    
      public Consumer(Clerk clerk){
        this.clerk = clerk;
      }
      @Override
      public void run() {
        System.out.println("=========消费者开始消费产品========");
        while(true){
    
          try {
            Thread.sleep(90);
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
    
          //要求clerk去减少产品
          clerk.minusProduct();
        }
      }
    }
  4. 资源类(产品)

    java
    //资源类(产品)
    class Clerk {
      private int productNum = 0; // 产品数量
      private static final int MAX_PRODUCT = 20;
      private static final int MIN_PRODUCT = 1;
    
      //增加产品
      public synchronized void addProduct() {
        if(productNum < MAX_PRODUCT){
          productNum++;
          System.out.println(Thread.currentThread().getName() + "生产了第" + productNum + "个产品");
    
          //唤醒消费者
          this.notifyAll();
        }else{
          try {
            this.wait();
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
        }
      }
    
      //减少产品
      public synchronized void minusProduct() {
        if(productNum >= MIN_PRODUCT){
          System.out.println(Thread.currentThread().getName() +  "消费了第" + productNum + "个产品");
          productNum--;
    
          //唤醒生产者
          this.notifyAll();
        }else{
          try {
            this.wait();
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
        }
      }
    }

线程池

在 Java 并发编程中,线程池(Thread Pool) 是企业级开发中绝对的核心组件。前面的内容我们提到过,频繁地创建和销毁线程会极大地消耗系统资源(CPU 和内存),甚至导致内存溢出(OOM)。

线程池的核心思想是“池化技术”(类似于数据库连接池):提前创建好一批线程放在池子里,有任务来了就分配一个线程去执行,执行完了线程不销毁,而是回到池子里继续等待下一个任务

image-20260508153534904

java.util.concurrent

核心继承体系:

Java 中的线程池框架主要在 java.util.concurrent (JUC) 包下,核心体系如下:

接口或类描述
Executor 接口最顶层的接口,只有一个 execute(Runnable command) 方法,将任务的提交与任务的执行解耦。
ExecutorService 接口继承自 Executor,增加了很多管理线程池生命周期的方法(如 shutdown(), submit() 等)。
ThreadPoolExecutor线程池的最核心实现类。我们在生产环境中配置的线程池,底层绝大多数都是它的实例。
ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,专门用于处理定时任务和周期性任务。

线程池 API

JDK5.0之前,我们必须手动自定义线程池。从JDK5.0开始,Java内置线程池相关的API。在 java.util.concurrent 包下提供了线程池相关API:ExecutorServiceExecutors

API:Executors

在 Java 的并发编程中,java.util.concurrent.Executors 类扮演着“兵工厂”的角色。它是一个提供工厂方法和实用方法的工具类,主要用于快速创建各种配置的线程池(ExecutorService)、调度器(ScheduledExecutorService)、线程工厂(ThreadFactory)以及将 Runnable 转换为 Callable

企业级规范(如阿里《Java 开发手册》)强制禁止在生产环境中直接使用它来创建线程池,但深入了解 Executors 的核心 API,不仅是面试的必考点,更能帮助我们深刻理解 ThreadPoolExecutor 的参数配置逻辑。


Executors 类 API

  • static ExecutorService
    newFixedThreadPool()
    (int nThreads)固定大小线程池。创建一个核心线程数和最大线程数固定的线程池。
  • static ExecutorService
    newCachedThreadPool()
    缓存(弹性)线程池。创建一个可按需创建新线程的线程池。如果有空闲线程就复用,没有就新建。
  • static ExecutorService
    newSingleThreadExecutor()
    单线程化线程池。创建一个只有一个线程的线程池。
  • static ScheduledExecutorService
    newScheduledThreadPool()
    (int corePoolSize)定时调度线程池。创建一个支持定时及周期性任务执行的线程池。(它是 Timer 类的完美替代品)。

API:ExecutorService 接口

顶级接口 Executor 非常简陋,只有一个 execute(Runnable) 方法,只能用来“提交任务”。而 ExecutorService 继承了 Executor,它极大地扩充了功能,赋予了开发者管理线程池生命周期以及追踪异步任务执行结果的能力。

我们在实际开发中(无论是使用 Spring 的 @Async,还是手动创建 ThreadPoolExecutor),底层通常都是面向 ExecutorService 接口在编程。


ExecutorService 接口 API

提交任务

  • void execute()(Runnable command)执行任务/命令,一般用来执行 Runnable

  • Future<?> submit()(Runnable task)提交一个无返回值的任务
    调用 get() 方法在任务成功完成后会返回 null

  • Future<T> submit()(Callable<T> task)提交一个有返回值的任务
    调用 get() 可以获取任务的最终计算结果。

关闭线程池

  • void shutdown()()温和关闭。不再接受新任务,但会把队列里已经排队的任务执行完毕。
  • void shutdownNow()()激进关闭。不再接受新任务,尝试中断正在执行的任务,并返回队列中尚未执行的任务列表。

API:ThreadPoolExecutor 类@

要真正掌握线程池,必须理解 ThreadPoolExecutor 最全的构造方法中的 7 个参数。这是面试的必考点,也是线上调优的基石。

java
public ThreadPoolExecutor(
  int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime,
  TimeUnit unit,
  BlockingQueue<Runnable> workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler)

ThreadPoolExecutor 类 API

我们可以用“去银行办理业务”来通俗理解这 7 个参数:

ThreadPoolExecutor()(...),线程池的最核心实现类。我们在生产环境中配置的线程池,底层绝大多数都是它的实例。

  • int corePoolSize核心线程数。线程池中的“常驻”线程数量。
    比喻:银行平时开的固定窗口数(比如 2 个)。即使这 2 个窗口闲着,也不会关掉。

  • int maximumPoolSize最大线程数。线程池允许同时存在的最大线程数量。
    比喻:银行总共有 5 个窗口。当人特别多的时候,最多只能开 5 个窗口。

  • long keepAliveTime空闲线程存活时间。当线程池中的线程数量大于核心线程数时,多余的空闲线程等待新任务的最长时间。
    比喻:临时加开的 3 个窗口,如果闲置了 1 个小时(keepAliveTime),大堂经理就会把它们关掉,只保留原来的 2 个固定窗口。

  • TimeUnit unit时间单位keepAliveTime 的单位(如秒、毫秒等)。

  • BlockingQueue<Runnable>
    workQueue
    任务队列/阻塞队列。用来保存等待执行的任务的阻塞队列。
    比喻:银行的候客区(排队等候的座位)。当核心窗口都在忙时,新来的客户先去座位上坐着等。

  • ThreadFactory threadFactory线程工厂。用于创建新线程的工厂。通常我们会自定义它,主要为了给线程起一个有意义的业务名字,方便出 Bug 时看日志定位。

  • RejectedExecutionHandler
    handler
    拒绝策略。当线程池和任务队列都满了,如何处理继续加进来的新任务。
    比喻:银行 5 个窗口都在忙,候客区的座位也全坐满了。这时候大堂经理该怎么处理新来的客户(比如直接拒绝、或者让客户明天再来)。


示例:基础示例

  1. 创建多线程

    java
    class NumberThread implements Runnable{
      @Override
      public void run() {
        for(int i = 0;i <= 100;i++){
          if(i % 2 == 0){
            System.out.println(Thread.currentThread().getName() + ": " + i);
          }
        }
      }
    }
    
    class NumberThread1 implements Runnable{
      @Override
      public void run() {
        for(int i = 0;i <= 100;i++){
          if(i % 2 != 0){
            System.out.println(Thread.currentThread().getName() + ": " + i);
          }
        }
      }
    }
    
    class NumberThread2 implements Callable {
      @Override
      public Object call() throws Exception {
        int evenSum = 0;//记录偶数的和
        for(int i = 0;i <= 100;i++){
          if(i % 2 == 0){
            evenSum += i;
          }
        }
        return evenSum;
      }
    }
  2. 主线程

    java
    public class ThreadPoolTest {
      public static void main(String[] args) {
        //1. 提供指定线程数量的线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        ThreadPoolExecutor service1 = (ThreadPoolExecutor) service;
        //        //设置线程池的属性
        //        System.out.println(service.getClass());//ThreadPoolExecutor
        service1.setMaximumPoolSize(50); //设置线程池中线程数的上限
    
        //2.执行指定的线程的操作。需要提供实现Runnable接口或Callable接口实现类的对象
        service.execute(new NumberThread());//适合适用于Runnable
        service.execute(new NumberThread1());//适合适用于Runnable
    
        try {
          Future future = service.submit(new NumberThread2());//适合使用于Callable
          System.out.println("总和为:" + future.get());
        } catch (Exception e) {
          e.printStackTrace();
        }
        //3.关闭连接池
        service.shutdown();
      }
    }

线程池执行流程

线程池的执行流程(极其重要!):

当一个新的任务通过 execute() 提交到线程池时,它的处理流程如下(注意这里的顺序,很多人会搞错):

  1. 判断核心线程数: 如果当前运行的线程数 < corePoolSize,即使有其他核心线程是空闲的,也会优先创建新的核心线程来执行该任务。

  2. 判断任务队列: 如果当前运行的线程数 >= corePoolSize,线程池会把新任务塞进 workQueue(任务队列) 中排队等待。

  3. 判断最大线程数: 如果 workQueue满了,且当前运行的线程数 < maximumPoolSize,线程池会创建非核心(临时)线程来立刻处理新提交的任务。(注意:此时临时线程处理的是刚提交的新任务,而不是队列里的老任务)

  4. 执行拒绝策略: 如果当前运行的线程数已经达到了 maximumPoolSize,且 workQueue 也满了,线程池就会执行 handler 设定的拒绝策略

禁止使用 Executors

为什么阿里巴巴《Java 开发手册》强制禁止使用 Executors:

Java 官方在 Executors 工具类中提供了一些快捷创建线程池的方法,但企业级开发中通常严禁使用

  • Executors.newFixedThreadPoolnewSingleThreadExecutor
    • 它们底层使用的队列是 LinkedBlockingQueue,这是一个无界队列(默认容量是 Integer.MAX_VALUE 约 21 亿)。
    • 风险: 如果任务堆积,队列会无限制地疯狂膨胀,最终导致内存溢出(OOM)。
  • Executors.newCachedThreadPoolnewScheduledThreadPool
    • 它们允许创建的最大线程数是 Integer.MAX_VALUE
    • 风险: 如果并发极高,会创建成千上万个线程,不仅消耗尽 CPU 资源,同样会导致 OOM。

正确做法: 永远使用 ThreadPoolExecutor 的构造方法手动创建线程池,明确指定队列的类型、大小以及合适的拒绝策略,做到“心中有数”。

execute() vs submit()

提交任务:execute() vs submit():

向线程池提交任务主要有两种方法:

方法参数返回值异常处理
execute()只能接受 Runnablevoid (无返回值)任务抛出的未受检异常会直接在控制台打印,导致执行该任务的线程终止(线程池会补充新线程)。
submit()可以接受 RunnableCallableFuture<?> (可获取结果)异常会被吞掉。只有当你调用 Future.get() 方法时,才会抛出底层的异常。